Skip to content

fix(decode): stop infinite loop on non-object repeated message elements - #6

Open
Fyzu wants to merge 3 commits into
mainfrom
claude/vigilant-archimedes-ygynf3
Open

Fyzu wants to merge 3 commits into
mainfrom
claude/vigilant-archimedes-ygynf3

Conversation

@Fyzu

@Fyzu Fyzu commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A tiny untrusted payload can make decoding loop until OutOfMemoryError:

BuffJson.decoder().decode("{\"repeatedNested\":[1]}", TestNesting.class); // never returns, then OOM

When a message reader met a non-object token, it returned an empty message without consuming the token. It ignored nextIfObjectStart()'s result, and readFieldName() returns null on a non-name token, which ended the field loop. Inside a repeated field, the enclosing while (!reader.nextIfArrayEnd()) loop then saw the same token forever, appending messages until the heap was exhausted.

Inputs that looped before this change (-Xmx96m, 8 s timeout):

input codegen typed reflection
{"repeatedNested":[1]}, [true], [[]], [{..},2] OOM OOM OOM
{"repeatedNested":[null]} OOM skipped skipped
{"repeatedNested":5}, {"repeatedNested":{}} (not an array) OOM OOM OOM
{"repeatedStruct":[1]}, [[1]] OOM OOM OOM
{"repeatedStruct":[null]} OOM skipped skipped
{"repeatedEmpty":[1]}, [[]] ok OOM OOM

Singular and map values were rejected only by accident: the leftover token made the top-level "input not end" check fail. The official conformance cases for null repeated elements appeared to pass only because ConformanceTestee catches Throwable, which reported the OutOfMemoryError as a parse failure.

Fix

1. Every reader consumes the container it expects, or throws a JSONException that names the proto type:

  • { for messages, maps, Struct, Any and Empty;
  • [ for repeated fields and ListValue.

The checks live in two helpers, FieldReader.requireObjectStart and requireArrayStart, and the generated decoders call them too. So all three paths produce the same error, for example Expected a JSON object for message io.suboptimal.buffjson.proto.NestedMessage, offset 32, …. Changed code:

  • DecoderGenerator — message, repeated, map and inline Empty reads
  • ProtobufMessageReader, TypedMessageReaderSchema, FieldReader.readRepeated/readMap
  • WellKnownTypes.readStruct/readListValue/readAny

Every element read now consumes at least its opening token, so each iteration of an array loop makes progress. Termination no longer depends on what the element reader does. The checks test a boolean the reader already returned, so there is no cost on the success path. The BuffJsonGeneratedDecoder.readMessage Javadoc now requires this consume-or-throw behavior.

2. Null elements in repeated fields, found by the same test matrix:

  • Codegen now rejects a null element with a JSONException (FieldReader.requireNonNullElement), as JsonFormat does. Before, it threw NullPointerException (Timestamp, Duration, FieldMask, String/BytesValue) or added a phantom default element (int64, bool, enum, wrappers).
  • google.protobuf.Value / NullValue elements keep null as a value on every path (a wrapped NullValue, or NULL_VALUE). The typed and reflection paths used to drop them, so {"repeatedValue":[null,1]} lost an element, even though that is exactly what the encoder writes.

3. Null map values of Value / NullValue type are kept on every path (FieldReader.nullValueFor), as JsonFormat and the encoder expect. Before, codegen dropped the entry, and the runtime paths stored an empty Value with no kind set, so a round trip lost data. A single shared WellKnownTypes.NULL_JSON_VALUE now serves every reader.

4. Empty input. Empty or whitespace-only input decodes to null from every overload (byte[], slice, InputStream), like an empty String. This is now documented on BuffJsonDecoder. Without it, the new object-start check would have turned these inputs into exceptions.

Behavior changes (invalid, empty or null input only)

  • Mismatched containers: non-object and non-array containers now fail with a clear JSONException instead of looping or failing on the trailing-input check. Valid proto3 JSON always uses these shapes, and JsonFormat rejects everything else.
  • Codegen Empty: a bare number or array for google.protobuf.Empty is now rejected, matching the runtime paths.
  • Codegen null elements are now rejected (see 2).
  • Null Value/NullValue elements and map values are kept as values on every path (see 2 and 3).
  • Empty input: empty byte[]/InputStream or whitespace-only input now returns null. On main it returned an empty message; an empty String already returned null.

Tests

  • New BuffJsonMalformedContainerTest: each case runs on all three decode paths inside assertTimeoutPreemptively. It covers:

    • non-object repeated message, Struct, ListValue, Any and Empty elements;
    • non-array repeated values;
    • non-object map values, singular message values and top-level input;
    • malformed objects inside containers;
    • truncated input and null repeated message elements (termination only);
    • null scalar and WKT elements;
    • repeated Value, repeated NullValue, map<_, Value> and map<_, NullValue> nulls, checked against JsonFormat plus an encoder round trip;
    • empty input from every overload.

    Against the unfixed code, the forked test JVM died with Java heap space.

  • New test messages: TestRepeatedNullValue and TestNullMapValues were added to the test protos so the generated null branches are compiled and exercised.

  • mvn -B clean verify: all modules pass (730 tests in buff-json-tests), including the benchmark and conformance-testee builds.

  • Not run locally: the official conformance_test_runner. CI runs it for all three BUFFJSON_PATHs.

Consumers must rebuild with mvn clean install to regenerate decoders. The protobuf Maven plugin only regenerates when .proto inputs change.

Not changed (possible follow-ups)

  • Decoders generated by the old plugin still loop, because generated decoders call each other directly and no runtime guard can reach them. They need regeneration, or a plugin/runtime version check.
  • Null elements in non-Value repeated fields are still skipped by the typed and reflection paths, while codegen rejects them.
  • Null map values of other types: codegen drops the entry, while the runtime paths insert the default value, and neither matches JsonFormat. runtimeNullMapValuesAndUnknownEnumNumbersMatchReflection pins the runtime behavior, so this needs a decision on which is intended.
  • A member without a name still ends an object early, as before. So an unclosed object such as [{"a":1, {"a":2}] is still accepted as two elements. The existing truncated-object leniency (BuffJsonErrorTest.truncatedObjectParsesLeniently) is unchanged.
  • Pre-existing issues seen while probing, unrelated to this diff:
    • readUnsignedLong throws NullPointerException for an object element ({"repeatedUint64":[{}]}).
    • fastjson2 silently coerces some wrong-typed elements: [{}] or [1.5] for an enum, [true] for a double, [1] for a StringValue.
    • fastjson2 treats a leading U+001A as end of input.
  • assertTimeoutPreemptively can't stop a runaway decode thread. If the fix regresses, the test fails at the timeout, but the fork may still run out of memory afterwards.

🤖 Generated with Claude Code

https://claude.ai/code/session_013AbdzHqfNXSdynywckLnWY

A message reader that met a non-object token returned an empty message
without consuming the token. Inside a repeated field the enclosing
array loop then saw the same token forever and appended messages until
OutOfMemoryError: {"repeatedMsg":[1]} or [true] on all three decode
paths, {"repeatedMsg":[null]} on the codegen path, repeated Struct and
Empty elements, and non-array values for repeated message fields.

Every reader now consumes the container it expects or throws a
JSONException naming the proto type: '{' for messages, maps, Struct,
Any and Empty, '[' for repeated fields and ListValue. The checks live
in FieldReader.requireObjectStart/requireArrayStart, which generated
decoders call too, so all paths report the same error. Valid proto3
JSON always has these shapes, so no valid input changes behavior.

Codegen also no longer accepts a bare number or array as
google.protobuf.Empty, matching the runtime paths.

BuffJsonMalformedContainerTest runs 209 malformed-container cases on
all three paths under a timeout. Before the fix the test JVM died with
"Java heap space".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AbdzHqfNXSdynywckLnWY
@Fyzu
Fyzu force-pushed the claude/vigilant-archimedes-ygynf3 branch from b2e4ee6 to f56e58e Compare September 28, 2026 23:53
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Performance comparison

Workflow and raw JMH artifacts
Commit: b0e72df

Java 21 performance

Base: ad10fe5 → candidate: b0e72df
Shared benchmark source: ddb898473de6f0f66081d1bdef7adf18cd447119

Throughput alerts are advisory. Existing allocation budgets are enforced separately.
A timing signal needs at least 10% change and separated JMH 99.9% intervals; otherwise it is inconclusive.
Allocation alerts need both >5% and >16 B/op growth (or >16 B/op from zero).

Benchmark Base ops/s Candidate ops/s Change Timing B/op base → candidate Allocation
simpleCodegenUtf16 10,950,406 ±119,092 10,610,418 ±138,017 -3.1% inconclusive 295.5 → 295.5 within alert threshold
simpleCodegenUtf8 11,029,882 ±141,152 10,999,310 ±352,120 -0.3% inconclusive 271.5 → 271.5 within alert threshold
simpleTypedUtf16 7,111,492 ±63,706 7,149,804 ±299,726 +0.5% inconclusive 295.5 → 295.5 within alert threshold
simpleTypedUtf8 7,355,051 ±275,935 7,490,213 ±44,376 +1.8% inconclusive 271.5 → 271.5 within alert threshold
simpleReflectionUtf16 3,854,190 ±49,192 3,746,083 ±126,817 -2.8% inconclusive 340.0 → 340.0 within alert threshold
simpleReflectionUtf8 3,924,716 ±201,101 3,829,837 ±191,904 -2.4% inconclusive 316.0 → 316.0 within alert threshold
complexCodegenUtf16 694,547 ±10,076 686,876 ±17,914 -1.1% inconclusive 1481.9 → 1481.9 within alert threshold
complexCodegenUtf8 760,768 ±95,035 717,179 ±3,806 -5.7% inconclusive 1433.9 → 1457.9 within alert threshold
complexTypedUtf16 653,419 ±6,095 662,286 ±4,364 +1.4% inconclusive 1289.9 → 1289.9 within alert threshold
complexTypedUtf8 676,319 ±9,283 665,514 ±2,093 -1.6% inconclusive 1265.9 → 1265.9 within alert threshold
complexReflectionUtf16 380,813 ±5,126 382,514 ±5,506 +0.4% inconclusive 1353.9 → 1353.9 within alert threshold
complexReflectionUtf8 399,554 ±3,350 389,874 ±4,648 -2.4% inconclusive 1329.9 → 1329.9 within alert threshold
mapCodegenUtf16 139,853 ±1,443 139,545 ±1,310 -0.2% inconclusive 5025.2 → 5025.3 within alert threshold
mapCodegenUtf8 133,507 ±655 129,123 ±1,093 -3.3% inconclusive 5001.2 → 5001.2 within alert threshold
mapTypedUtf16 96,304 ±975 95,386 ±2,058 -1.0% inconclusive 4805.5 → 4805.5 within alert threshold
mapTypedUtf8 101,949 ±739 102,451 ±1,082 +0.5% inconclusive 4781.5 → 4781.5 within alert threshold
mapReflectionUtf16 59,893 ±995 59,159 ±532 -1.2% inconclusive 7634.1 → 7634.2 within alert threshold
mapReflectionUtf8 60,330 ±1,558 59,705 ±487 -1.0% inconclusive 7610.2 → 7610.1 within alert threshold
structCodegenUtf16 745,136 ±6,221 762,636 ±11,446 +2.3% inconclusive 735.7 → 735.7 within alert threshold
structCodegenUtf8 703,268 ±11,834 708,101 ±21,625 +0.7% inconclusive 711.7 → 711.7 within alert threshold
structTypedUtf16 753,771 ±5,965 759,747 ±10,260 +0.8% inconclusive 735.7 → 735.7 within alert threshold
structTypedUtf8 706,858 ±34,727 700,302 ±20,857 -0.9% inconclusive 711.7 → 711.7 within alert threshold
structReflectionUtf16 725,875 ±17,445 693,436 ±72,564 -4.5% inconclusive 735.7 → 799.5 review increase
structReflectionUtf8 665,624 ±15,560 668,194 ±17,637 +0.4% inconclusive 711.7 → 711.7 within alert threshold
timestampCodegenUtf16 4,210,123 ±99,542 4,117,752 ±39,739 -2.2% inconclusive 464.0 → 464.0 within alert threshold
timestampCodegenUtf8 4,582,798 ±19,656 4,473,581 ±181,969 -2.4% inconclusive 440.0 → 440.0 within alert threshold
timestampTypedUtf16 3,400,522 ±16,729 3,471,069 ±120,859 +2.1% inconclusive 464.0 → 464.0 within alert threshold
timestampTypedUtf8 3,744,027 ±15,182 3,731,290 ±45,171 -0.3% inconclusive 440.0 → 440.0 within alert threshold
timestampReflectionUtf16 2,608,174 ±79,263 2,614,501 ±84,130 +0.2% inconclusive 464.0 → 464.0 within alert threshold
timestampReflectionUtf8 2,770,112 ±40,560 2,741,754 ±241,558 -1.0% inconclusive 440.0 → 440.0 within alert threshold

Java 25 performance

Base: ad10fe5 → candidate: b0e72df
Shared benchmark source: ddb898473de6f0f66081d1bdef7adf18cd447119

Throughput alerts are advisory. Existing allocation budgets are enforced separately.
A timing signal needs at least 10% change and separated JMH 99.9% intervals; otherwise it is inconclusive.
Allocation alerts need both >5% and >16 B/op growth (or >16 B/op from zero).

Benchmark Base ops/s Candidate ops/s Change Timing B/op base → candidate Allocation
simpleCodegenUtf16 12,201,105 ±381,554 12,494,220 ±171,551 +2.4% inconclusive 295.5 → 295.5 within alert threshold
simpleCodegenUtf8 15,154,171 ±3,231,955 15,407,794 ±2,293,074 +1.7% inconclusive 271.5 → 271.5 within alert threshold
simpleTypedUtf16 10,415,927 ±1,806,148 10,728,168 ±675,808 +3.0% inconclusive 295.5 → 295.5 within alert threshold
simpleTypedUtf8 11,235,714 ±579,911 10,125,178 ±893,444 -9.9% inconclusive 271.5 → 271.5 within alert threshold
simpleReflectionUtf16 5,865,364 ±153,988 5,829,416 ±195,241 -0.6% inconclusive 340.0 → 340.0 within alert threshold
simpleReflectionUtf8 5,412,502 ±298,162 6,063,920 ±500,285 +12.0% inconclusive 316.0 → 316.0 within alert threshold
complexCodegenUtf16 1,081,024 ±51,358 1,087,209 ±43,946 +0.6% inconclusive 1481.9 → 1481.9 within alert threshold
complexCodegenUtf8 1,210,047 ±57,813 1,170,186 ±103,158 -3.3% inconclusive 1457.9 → 1457.9 within alert threshold
complexTypedUtf16 1,079,442 ±28,462 1,048,351 ±61,516 -2.9% inconclusive 1289.9 → 1289.9 within alert threshold
complexTypedUtf8 1,142,353 ±46,692 1,131,352 ±92,706 -1.0% inconclusive 1265.9 → 1265.9 within alert threshold
complexReflectionUtf16 688,796 ±16,731 690,514 ±6,078 +0.2% inconclusive 1353.9 → 1353.9 within alert threshold
complexReflectionUtf8 685,122 ±72,438 719,071 ±10,379 +5.0% inconclusive 1329.9 → 1329.9 within alert threshold
mapCodegenUtf16 159,362 ±15,488 165,482 ±2,460 +3.8% inconclusive 4589.5 → 4589.5 within alert threshold
mapCodegenUtf8 168,727 ±9,233 168,935 ±7,972 +0.1% inconclusive 4565.5 → 4565.5 within alert threshold
mapTypedUtf16 131,052 ±1,910 130,157 ±3,425 -0.7% inconclusive 4805.5 → 4805.5 within alert threshold
mapTypedUtf8 140,198 ±8,850 138,903 ±12,887 -0.9% inconclusive 4781.5 → 4781.5 within alert threshold
mapReflectionUtf16 83,439 ±7,801 86,874 ±2,619 +4.1% inconclusive 7634.1 → 7634.2 within alert threshold
mapReflectionUtf8 88,758 ±2,500 87,878 ±4,361 -1.0% inconclusive 7610.1 → 7610.1 within alert threshold
structCodegenUtf16 737,207 ±55,743 745,497 ±37,895 +1.1% inconclusive 735.7 → 735.7 within alert threshold
structCodegenUtf8 745,305 ±21,389 721,507 ±36,044 -3.2% inconclusive 711.7 → 711.7 within alert threshold
structTypedUtf16 740,453 ±50,536 739,313 ±22,343 -0.2% inconclusive 735.7 → 735.7 within alert threshold
structTypedUtf8 756,702 ±42,664 726,373 ±44,942 -4.0% inconclusive 711.7 → 711.7 within alert threshold
structReflectionUtf16 721,028 ±17,845 719,418 ±36,498 -0.2% inconclusive 735.7 → 735.7 within alert threshold
structReflectionUtf8 721,338 ±45,362 735,795 ±24,386 +2.0% inconclusive 711.7 → 711.7 within alert threshold
timestampCodegenUtf16 6,033,084 ±138,712 5,999,994 ±140,027 -0.5% inconclusive 464.0 → 464.0 within alert threshold
timestampCodegenUtf8 6,130,960 ±317,824 6,008,577 ±422,443 -2.0% inconclusive 440.0 → 440.0 within alert threshold
timestampTypedUtf16 5,504,683 ±301,417 5,503,729 ±402,442 -0.0% inconclusive 464.0 → 464.0 within alert threshold
timestampTypedUtf8 6,603,980 ±397,851 6,377,454 ±482,530 -3.4% inconclusive 440.0 → 440.0 within alert threshold
timestampReflectionUtf16 4,183,899 ±225,037 4,288,072 ±40,047 +2.5% inconclusive 464.0 → 464.0 within alert threshold
timestampReflectionUtf8 4,883,157 ±58,153 4,896,946 ±10,532 +0.3% inconclusive 440.0 → 440.0 within alert threshold

… checks

Follow-ups from review of the infinite-loop fix:

- Codegen rejects null repeated elements with a JSONException
  (FieldReader.requireNonNullElement), as JsonFormat does. They used to
  throw NullPointerException (Timestamp, Duration, FieldMask,
  String/BytesValue) or add a phantom default element (int64, bool,
  enum, wrappers). Value elements keep null-as-NullValue, and NullValue
  elements now map null to NULL_VALUE.
- The typed and reflection repeated readers keep null elements of
  repeated Value/NullValue as values (FieldReader.nullValueFor) instead
  of dropping them, matching codegen, JsonFormat and the encoder's
  output. Other null elements are still skipped on those paths.
- Empty or whitespace-only input decodes to null from every overload
  (byte[], slice, InputStream), like an empty String. The new
  object-start check would otherwise have turned these into exceptions.
- Hoist the descriptor in readMessage(JSONReader, Builder), use the
  FIELD_READER constant for every generated FieldReader reference, and
  correct the docs.

Adds TestRepeatedNullValue to the test protos so the generated
NullValue branch is compiled and tested on all three paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AbdzHqfNXSdynywckLnWY
A null map value is a value for map<K, google.protobuf.Value> (a
wrapped NullValue) and map<K, google.protobuf.NullValue> (NULL_VALUE),
as in JsonFormat and as the encoder writes them. Codegen dropped such
entries, and the runtime paths stored an empty Value (no kind set), so
the paths disagreed and an encode/decode round trip lost data.

- Generated map loops no longer skip null for Value maps (readJsonValue
  returns NullValue itself) and emit putXValue(key, 0) for NullValue
  maps. Other value types keep the existing null handling.
- FieldReader.readMap and the typed map parser use nullValueFor for
  Value maps.
- WellKnownTypes.NULL_JSON_VALUE is now the single shared NullValue
  Value, used by readJsonValue, packed Any and nullValueFor.
- Javadoc: BuffJsonGeneratedDecoder.readMessage must consume or throw
  (regenerate decoders from older plugins), and BuffJsonDecoder returns
  null for empty input.

Adds TestNullMapValues to the test protos, with a test that checks
JsonFormat parity and the encoder round trip on all three paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AbdzHqfNXSdynywckLnWY
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants